Repository navigation
Conversation
What was broken: the upstream runtime's workspace write path (`resolveWorkspaceWritePath` in `packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts`) falls back to the lexical spelling when `realpath` fails. A dangling symlink fails `realpath` exactly as a file that is not there yet does, but a write through a dangling link creates the file it points at — so the write landed outside the workspace while the check compared a path inside it. The upstream fix for this was merged and then reverted (#240), because it touched packages this branch does not own. Why this fix: the same fence is reimplemented on the WebUI side, where the binding that forwards a workspace write is ours. `packages/webui/src/runtime/workspace-real-path.ts` resolves a path to the location the filesystem really uses for it — following symlinks, and following dangling ones by hand — and compares it against the real workspace root. A path that cannot be resolved at all (a symlink cycle, a permission error, a missing root) is refused rather than guessed at, and a symlink that stays inside the workspace keeps working. The fence is wired into the two WebUI bindings that write into a workspace: `applyCanvas`, whose operation names workspace files, and `extractWorkspaceArchive`, whose destination is created before anything is written into it. It validates only: the path reaches the runtime exactly as it was spelled, and the refusal is reported in the WebUI's own voice. How it was verified: with real temp directories and real symlinks. A dangling symlink pointing outside the workspace is refused and the file it points at is never created; a symlink pointing inside the workspace keeps working; a workspace root that is itself a symlink keeps working; an ordinary path is accepted; a symlink cycle is refused instead of followed. The harness half of the suite proves the runtime is never called for a path that leaves the workspace — both refusals fail without the fence. Known boundaries: the upstream `realpathOrFallback` is still live, so a write that does not pass through a WebUI binding is still exposed there. The fence cannot close the gap between its `realpath` and the write itself, so a symlink swapped in between the two is still a race. A shell run in the workspace is out of scope.
modacker
force-pushed
the
fix/webui-workspace-write-real-path-fence
branch
from
October 23, 2026 08:09
06163a8 to
74edf24
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was broken
The runtime's workspace write path (
resolveWorkspaceWritePathinpackages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts)resolves the write target with
realpathand, when that fails, falls back tothe lexical spelling (
realpathOrFallback).A dangling symlink fails
realpathexactly as a file that does not exist yetdoes — but a write through a dangling link creates the file it points at. So the
fallback compared a path inside the workspace (
<workspace>/link) against thereal root, passed, and the file was really created outside the workspace, at
whatever the link pointed at.
The upstream fix for this was merged as PR #238 and reverted in PR #240, because
it touched the upstream packages (
local-runtime-v2,agent-tools) that thisbranch is not allowed to change.
Why this fix has to live on the WebUI side
The branch's hard boundary is
packages/webui: the sixteen upstream packagesmust not diverge from
MiniMax-AI/minimax-code. The same fence is thereforereimplemented here, on the WebUI side of the boundary, where the binding that
forwards a workspace write is ours.
This is the WebUI-side equivalent of the reverted upstream fix — the same
algorithm (
realpaththe target; when it does not exist, resolve its nearestexisting ancestor and re-attach the missing segments; follow a dangling link
by hand), the same refusal rules, and the same "validate only, never change
the path" discipline.
Implementation
packages/webui/src/runtime/workspace-real-path.ts(new)resolveRealPath(path)— the real location of a path.realpathfirst; onENOENT/ENOTDIRit resolves the nearest existing ancestor and re-attachesthe missing segments. A path that is a dangling symlink is followed by hand
(
readlink+ the link's own target, relative links resolved against thereal holding directory), because that is the case
realpathcannotexpress. A hop limit of 32 stops a symlink cycle.
isInsideDirectory(root, candidate)—path.relative-based containment(a string prefix would accept
/tmp/evilfor a root of/tmp/ev).checkWorkspaceFence(root, target)— the fence. The workspace root isresolved with
realpathtoo, because a workspace root is allowed to be asymlink and comparing a real target against a lexical root would refuse every
path in such a workspace.
Refusal rules:
outsideunresolvableunresolvableUnresolvable is a refusal, not a guess.
packages/webui/src/runtime/harness/workspace.ts(modified) — the fence iswired into the two WebUI bindings that write into a workspace:
applyCanvas— the canvas operation names workspace files(
add_file.relativePath, workspace-relative, client-supplied). The root theyare relative to is the session's workspace, which the fence asks the
runtime for (
getSession), the same root the runtime resolves on its side.The fence runs before
cliService.applyCanvasis called.extractWorkspaceArchive— the destination is a browser-chosen path that theextraction creates before writing anything into it. Without the fence,
prepareExtractionRootranmkdir -pon the requested destination beforeits containment check, so a destination such as
link/extracted(withlinka symlink to a directory outside the workspace) created
<outside>/extractedand was only refused afterwards. The fence now runs before the extraction
creates anything.
Both refusals are explicit and readable, in the voice of the surface they
answer:
解压目标越界:…/解压目标无法解析:…for the archive (the wordingthe archive reader already uses) and
Canvas file path escapes the workspace: …/
Canvas file path cannot be resolved: …for the canvas (the wordingcanvas-input.tsalready uses).The fence only validates. The path is handed to the runtime exactly as it
was spelled — how it is joined, and what is passed upstream, is unchanged.
What the tests cover
packages/webui/test/unit/workspace-write-fence.test.ts(new) — real tempdirectories and real symlinks, because the bug is about what the filesystem
does with a link:
file it points at is never created. The suite first writes through the link
directly, to show the escape is real without the fence, and removes that
file before asserting the fenced write creates nothing outside;
the file the link points at;
directory the extraction would have created outside the workspace does not
appear;
The harness half of the suite proves the fence runs before the runtime is
asked to write: the host is never called for a path that leaves the workspace,
and no file appears outside it. All three refusal tests fail without the
fence — they were checked by removing the two fence calls: the canvas case
then resolved instead of rejecting, the archive case then created the directory
outside the workspace.
Known boundaries
realpathOrFallbackis still live. A write that does not passthrough a WebUI binding is still exposed there; this fence is the WebUI
side's own protection, not a replacement for the upstream fix.
realpathwith the write that follows it. A symlinkswapped in between the two is still a race — the same window the upstream
fix has.
answer
getSessionleaves the root unknown, and the fence is skipped forthat call — the runtime then resolves the canvas path against the session's
own workspace and refuses a path that is not inside it.
add_file.relativePathwith
realpathand requires an existing file inside the workspace, so thecanvas half of this fence is a second, independent gate in front of that
call rather than the gate that closes a currently reachable canvas escape.
The archive half is the one that closes a currently reachable WebUI-side
escape.
How it was validated
release/public-source.jsonwas regenerated withnode scripts/source-inventory.mjs --write; the new vitest suite isregistered in
test/vitest-suites.json; the frozen path-metadata oracle(
packages/webui/test/gates/fixtures/path-metadata-f00.json) gained the onenew source path, classified with the frozen baseline classifier.